Add admin user management interface

Dominik Sander %!s(int64=8) %!d(string=hace) años
padre
commit
4722ebfe4e

+ 76 - 0
app/controllers/admin/users_controller.rb

@@ -0,0 +1,76 @@
1
+class Admin::UsersController < ApplicationController
2
+  before_action :authenticate_admin!
3
+
4
+  before_action :find_user, only: [:edit, :destroy, :update]
5
+
6
+  helper_method :resource
7
+
8
+  def index
9
+    @users = User.reorder(:created_at).page(params[:page])
10
+
11
+    respond_to do |format|
12
+      format.html
13
+      format.json { render json: @users }
14
+    end
15
+  end
16
+
17
+  def new
18
+    @user = User.new
19
+  end
20
+
21
+  def create
22
+    admin = params[:user].delete(:admin)
23
+    @user = User.new(params[:user])
24
+    @user.requires_no_invitation_code!
25
+    @user.admin = admin
26
+
27
+    respond_to do |format|
28
+      if @user.save
29
+        format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was successfully created." }
30
+        format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
31
+      else
32
+        format.html { render action: 'new' }
33
+        format.json { render json: @user.errors, status: :unprocessable_entity }
34
+      end
35
+    end
36
+  end
37
+
38
+  def edit
39
+  end
40
+
41
+  def update
42
+    admin = params[:user].delete(:admin)
43
+    params[:user].except!(:password, :password_confirmation) if params[:user][:password].blank?
44
+    @user.assign_attributes(params[:user])
45
+    @user.admin = admin
46
+
47
+    respond_to do |format|
48
+      if @user.save
49
+        format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was successfully updated." }
50
+        format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
51
+      else
52
+        format.html { render action: 'edit' }
53
+        format.json { render json: @user.errors, status: :unprocessable_entity }
54
+      end
55
+    end
56
+  end
57
+
58
+  def destroy
59
+    @user.destroy
60
+
61
+    respond_to do |format|
62
+      format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was deleted." }
63
+      format.json { head :no_content }
64
+    end
65
+  end
66
+
67
+  private
68
+
69
+  def find_user
70
+    @user = User.find(params[:id])
71
+  end
72
+
73
+  def resource
74
+    @user
75
+  end
76
+end

+ 10 - 2
app/models/user.rb

@@ -16,9 +16,9 @@ class User < ActiveRecord::Base
16 16
   attr_accessible *(ACCESSIBLE_ATTRIBUTES + [:admin]), :as => :admin
17 17
 
18 18
   validates_presence_of :username
19
-  validates_uniqueness_of :username
19
+  validates :username, uniqueness: { case_sensitive: false }
20 20
   validates_format_of :username, :with => /\A[a-zA-Z0-9_-]{3,15}\Z/, :message => "can only contain letters, numbers, underscores, and dashes, and must be between 3 and 15 characters in length."
21
-  validates_inclusion_of :invitation_code, :on => :create, :in => INVITATION_CODES, :message => "is not valid", if: ->{ User.using_invitation_code? }
21
+  validates_inclusion_of :invitation_code, :on => :create, :in => INVITATION_CODES, :message => "is not valid", if: -> { !requires_no_invitation_code? && User.using_invitation_code? }
22 22
 
23 23
   has_many :user_credentials, :dependent => :destroy, :inverse_of => :user
24 24
   has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user
@@ -44,4 +44,12 @@ class User < ActiveRecord::Base
44 44
   def self.using_invitation_code?
45 45
     ENV['SKIP_INVITATION_CODE'] != 'true'
46 46
   end
47
+
48
+  def requires_no_invitation_code!
49
+    @requires_no_invitation_code = true
50
+  end
51
+
52
+  def requires_no_invitation_code?
53
+    !!@requires_no_invitation_code
54
+  end
47 55
 end

+ 26 - 0
app/views/admin/users/_form.html.erb

@@ -0,0 +1,26 @@
1
+<%= form_for([:admin, @user], html: { class: 'form-horizontal' }) do |f| %>
2
+  <%= devise_error_messages! %>
3
+  <%= render partial: '/devise/registrations/common_registration_fields', locals: { f: f } %>
4
+
5
+  <div class="form-group">
6
+    <div class="col-md-offset-4 col-md-10">
7
+      <%= f.label :admin do %>
8
+        <%= f.check_box :admin %> Admin
9
+      <% end %>
10
+    </div>
11
+  </div>
12
+
13
+  <div class="form-group">
14
+    <div class="col-md-offset-4 col-md-10">
15
+      <%= f.submit class: "btn btn-primary" %>
16
+    </div>
17
+  </div>
18
+<% end %>
19
+
20
+<hr>
21
+
22
+<div class="row">
23
+  <div class="col-md-12">
24
+    <%= link_to icon_tag('glyphicon-chevron-left') + ' Back'.html_safe, admin_users_path, class: "btn btn-default" %>
25
+  </div>
26
+</div>

+ 9 - 0
app/views/admin/users/edit.html.erb

@@ -0,0 +1,9 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <h2>Edit User</h2>
5
+
6
+      <%= render partial: 'form' %>
7
+    </div>
8
+  </div>
9
+</div>

+ 48 - 0
app/views/admin/users/index.html.erb

@@ -0,0 +1,48 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Users
7
+        </h2>
8
+      </div>
9
+
10
+      <div class='table-responsive'>
11
+        <table class='table table-striped events'>
12
+          <tr>
13
+            <th>Username</th>
14
+            <th>Email</th>
15
+            <th>State</th>
16
+            <th>Active agents</th>
17
+            <th>Inactive agents</th>
18
+            <th>Registered since</th>
19
+            <th>Options</th>
20
+          </tr>
21
+
22
+          <% @users.each do |user| %>
23
+            <tr>
24
+              <td><%= link_to user.username, edit_admin_user_path(user) %></td>
25
+              <td><%= user.email %></td>
26
+              <td>state</td>
27
+              <td><%= user.agents.active.count %></td>
28
+              <td><%= user.agents.inactive.count %></td>
29
+              <td title='<%= user.created_at %>'><%= time_ago_in_words user.created_at %> ago</td>
30
+              <td>
31
+                <div class="btn-group btn-group-xs">
32
+                  <%= link_to 'Delete', admin_user_path(user), method: :delete, data: { confirm: 'Are you sure? This can not be undone.' }, class: "btn btn-default" %>
33
+                </div>
34
+              </td>
35
+            </tr>
36
+          <% end %>
37
+        </table>
38
+      </div>
39
+
40
+      <%= paginate @users, theme: 'twitter-bootstrap-3' %>
41
+
42
+      <div class="btn-group">
43
+        <%= link_to icon_tag('glyphicon-plus') + ' New User', new_admin_user_path, class: "btn btn-default" %>
44
+      </div>
45
+    </div>
46
+  </div>
47
+</div>
48
+

+ 9 - 0
app/views/admin/users/new.html.erb

@@ -0,0 +1,9 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <h2>Create new User</h2>
5
+
6
+      <%= render partial: 'form' %>
7
+    </div>
8
+  </div>
9
+</div>

+ 28 - 0
app/views/devise/registrations/_common_registration_fields.html.erb

@@ -0,0 +1,28 @@
1
+<div class="form-group">
2
+  <%= f.label :email, class: 'col-md-4 control-label' %>
3
+  <div class="col-md-6">
4
+    <%= f.email_field :email, autofocus: true, class: 'form-control' %>
5
+  </div>
6
+</div>
7
+
8
+<div class="form-group">
9
+  <%= f.label :username, class: 'col-md-4 control-label' %>
10
+  <div class="col-md-6">
11
+    <%= f.text_field :username, class: 'form-control' %>
12
+  </div>
13
+</div>
14
+
15
+<div class="form-group">
16
+  <%= f.label :password, class: 'col-md-4 control-label' %>
17
+  <div class="col-md-6">
18
+    <%= f.password_field :password, autocomplete: "off", class: 'form-control' %>
19
+    <% if @validatable %><span class="help-inline"><%= @minimum_password_length %> characters minimum.</span><% end %>
20
+  </div>
21
+</div>
22
+
23
+<div class="form-group">
24
+  <%= f.label :password_confirmation, class: 'col-md-4 control-label' %>
25
+  <div class="col-md-6">
26
+    <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' %>
27
+  </div>
28
+</div>

+ 1 - 28
app/views/devise/registrations/new.html.erb

@@ -41,34 +41,7 @@ bin/setup_heroku
41 41
             </div>
42 42
           <% end %>
43 43
 
44
-          <div class="form-group">
45
-            <%= f.label :email, class: 'col-md-4 control-label' %>
46
-            <div class="col-md-6">
47
-              <%= f.email_field :email, autofocus: true, class: 'form-control' %>
48
-            </div>
49
-          </div>
50
-
51
-          <div class="form-group">
52
-            <%= f.label :username, class: 'col-md-4 control-label' %>
53
-            <div class="col-md-6">
54
-              <%= f.text_field :username, class: 'form-control' %>
55
-            </div>
56
-          </div>
57
-
58
-          <div class="form-group">
59
-            <%= f.label :password, class: 'col-md-4 control-label' %>
60
-            <div class="col-md-6">
61
-              <%= f.password_field :password, autocomplete: "off", class: 'form-control' %>
62
-              <% if @validatable %><span class="help-inline"><%= @minimum_password_length %> characters minimum.</span><% end %>
63
-            </div>
64
-          </div>
65
-
66
-          <div class="form-group">
67
-            <%= f.label :password_confirmation, class: 'col-md-4 control-label' %>
68
-            <div class="col-md-6">
69
-              <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' %>
70
-            </div>
71
-          </div>
44
+          <%= render partial: 'common_registration_fields', locals: { f: f } %>
72 45
 
73 46
           <div class="form-group">
74 47
             <div class="col-md-offset-4 col-md-10">

+ 3 - 0
app/views/layouts/_navigation.html.erb

@@ -74,6 +74,9 @@
74 74
           <li>
75 75
             <%= link_to 'Job Management', jobs_path, :tabindex => '-1' %>
76 76
           </li>
77
+          <li>
78
+            <%= link_to 'User Management', admin_users_path, tabindex: '-1' %>
79
+          </li>
77 80
         <% end %>
78 81
         <li>
79 82
           <%= link_to 'About', 'https://github.com/cantino/huginn', :tabindex => "-1" %>

+ 4 - 0
config/routes.rb

@@ -66,6 +66,10 @@ Huginn::Application.routes.draw do
66 66
     end
67 67
   end
68 68
 
69
+  namespace :admin do
70
+    resources :users, except: :show
71
+  end
72
+
69 73
   get "/worker_status" => "worker_status#show"
70 74
 
71 75
   match "/users/:user_id/web_requests/:agent_id/:secret" => "web_requests#handle_request", :as => :web_requests, :via => [:get, :post, :put, :delete]

+ 21 - 0
db/migrate/20160307085545_warn_about_duplicate_usernames.rb

@@ -0,0 +1,21 @@
1
+class WarnAboutDuplicateUsernames < ActiveRecord::Migration
2
+  def up
3
+    names = User.group('LOWER(username)').having('count(*) > 1').pluck('LOWER(username)')
4
+    if names.length > 0
5
+      puts "-----------------------------------------------------"
6
+      puts "--------------------- WARNiNG -----------------------"
7
+      puts "-------- Found users with duplicate usernames -------"
8
+      puts "-----------------------------------------------------"
9
+      puts "For the users to log in using their username they have to change it to a unique name"
10
+      names.each do |name|
11
+        puts
12
+        puts "'#{name}' is used multiple times:"
13
+        User.where(['LOWER(username) = ?', name]).each do |u|
14
+          puts "#{u.id}\t#{u.email}"
15
+        end
16
+      end
17
+      puts
18
+      puts
19
+    end
20
+  end
21
+end

+ 82 - 0
spec/features/admin_users_spec.rb

@@ -0,0 +1,82 @@
1
+require 'capybara_helper'
2
+
3
+describe Admin::UsersController do
4
+  it "requires to be signed in as an admin" do
5
+    login_as(users(:bob))
6
+    visit admin_users_path
7
+    expect(page).to have_text('Admin access required to view that page.')
8
+  end
9
+
10
+  context "as an admin" do
11
+    before :each do
12
+      login_as(users(:jane))
13
+    end
14
+
15
+    it "lists all users" do
16
+      visit admin_users_path
17
+      expect(page).to have_text('bob')
18
+      expect(page).to have_text('jane')
19
+    end
20
+
21
+    it "allows to delete a user" do
22
+      visit admin_users_path
23
+      find(:css, "a[href='/admin/users/#{users(:bob).id}']").click
24
+      expect(page).to have_text("User 'bob' was deleted.")
25
+      expect(page).not_to have_text('bob@example.com')
26
+    end
27
+
28
+    context "creating new users" do
29
+      it "follow the 'new user' link" do
30
+        visit admin_users_path
31
+        click_on('New User')
32
+        expect(page).to have_text('Create new User')
33
+      end
34
+
35
+      it "creates a new user" do
36
+        visit new_admin_user_path
37
+        fill_in 'Email', with: 'test@test.com'
38
+        fill_in 'Username', with: 'usertest'
39
+        fill_in 'Password', with: '12345678'
40
+        fill_in 'Password confirmation', with: '12345678'
41
+        click_on 'Create User'
42
+        expect(page).to have_text("User 'usertest' was successfully created.")
43
+        expect(page).to have_text('test@test.com')
44
+      end
45
+
46
+      it "requires the passwords to match" do
47
+        visit new_admin_user_path
48
+        fill_in 'Email', with: 'test@test.com'
49
+        fill_in 'Username', with: 'usertest'
50
+        fill_in 'Password', with: '12345678'
51
+        fill_in 'Password confirmation', with: 'no_match'
52
+        click_on 'Create User'
53
+        expect(page).to have_text("Password confirmation doesn't match")
54
+      end
55
+    end
56
+
57
+    context "updating existing users" do
58
+      it "follows the edit link" do
59
+        visit admin_users_path
60
+        click_on('bob')
61
+        expect(page).to have_text('Edit User')
62
+      end
63
+
64
+      it "updates an existing user" do
65
+        visit edit_admin_user_path(users(:bob))
66
+        check 'Admin'
67
+        click_on 'Update User'
68
+        expect(page).to have_text("User 'bob' was successfully updated.")
69
+        visit edit_admin_user_path(users(:bob))
70
+        expect(page).to have_checked_field('Admin')
71
+      end
72
+
73
+      it "requires the passwords to match when changing them" do
74
+        visit edit_admin_user_path(users(:bob))
75
+        fill_in 'Password', with: '12345678'
76
+        fill_in 'Password confirmation', with: 'no_match'
77
+        click_on 'Update User'
78
+        expect(page).to have_text("Password confirmation doesn't match")
79
+      end
80
+    end
81
+  end
82
+end

+ 6 - 0
spec/models/users_spec.rb

@@ -19,6 +19,12 @@ describe User do
19 19
             should_not allow_value(v).for(:invitation_code)
20 20
           end
21 21
         end
22
+
23
+        it "requires no authentication code when requires_no_invitation_code! is called" do
24
+          u = User.new(username: 'test', email: 'test@test.com', password: '12345678', password_confirmation: '12345678')
25
+          u.requires_no_invitation_code!
26
+          expect(u).to be_valid
27
+        end
22 28
       end
23 29
       
24 30
       context "when configured not to use invitation codes" do